# 第07章 单例模式

1548685120302

# 7.1 定义、类型、适用场景及优缺点

定义:保证一个类仅有一个实例,并提供一个全局访问点

类型:创建型

适用场景:想确保任何情况下都绝对只有一个实例;

优点

  • 在内存里只有一个实例,减少内存开销;
  • 可以避免对资源的多重占用;
  • 设置全局访问点,严格控制访问;

缺点

  • 没有接口,扩展困难

重点

  • 私有构造器;
  • 线程安全;
  • 延迟加载;
  • 序列化和反序列化安全;
  • 反射;

Double Check(双重检测)

1548685542257

1548685549022

静态内部类

1548685556269

注意

  • 反编译
  • 内存原理;
  • 多线程Debug;

# 7.2 懒汉式及多线程Debug

# 7.2.1 懒汉式单例模式及其问题

public class LazySingleton {
    private static LazySingleton lazySingleton = null;
    // 构造器私有,防止外部创建
    private LazySingleton(){}
    public static LazySingleton getInstance(){
        if(lazySingleton == null){
            lazySingleton = new LazySingleton();
        }
        return lazySingleton;
    }
}
1
2
3
4
5
6
7
8
9
10
11

可以看到,理想状态下,当线程第一次进入方法的时候,lazySingleton为null,则会创建新对象,而下次线程进入方法的时候,发现lazySingleton已经不为null,则会直接返回lazySingleton,从而控制只返回一个相同的对象;

但是,在多线程情况下事实并非如此,此时线程A进入到第7行代码,并未参数新对象,线程B刚好处于第6行代码,线程B会发现lazySingleton为null,则会进入并创建新对象,这样该方法就产生了两个不同的实例对象。

验证(多线程Debug):

创建线程任务:

public class T implements Runnable {
    public void run() {
        LazySingleton instance = LazySingleton.getInstance();
        System.out.println(Thread.currentThread().getName()+"  "+instance);
    }
}
1
2
3
4
5
6

创建Test:

public class Test {
    public static void main(String[] args) {
        Thread t1 = new Thread(new T());
        Thread t2 = new Thread(new T());
        t1.start();
        t2.start();
        System.out.println("program end");
    }
}
1
2
3
4
5
6
7
8
9

打断点,并右击断点设置为多线程方式debug:

1548687400016

在主线程中开始Debug:

1548687504075

这样,主线程就阻塞在断点处,等待t1和t2线程的执行

1548687568068

在IDEA的左下角,可以切换线程:

1548687647952

切换到Thread-0:

1548687685674

1548687705250

可见线程Thread-0在run方法中,Thread-0执行到下图处,再切换Thread-1并也运行到此处:

1548687891794

1548687908412

此时,两个线程的lazySingleton都为null,两个线程均会创建新实例

两个线程在打印之前均执行创建实例代码,并相继结束,结果如下: 1548688070652

此时,打印结果为两个相同的实例,可想而知,该实例为后执行的线程创建的实例。

如果两个线程不同时执行完,在一个线程打印完成后,另一个线程才继续执行,结果如下:

1548688205123

此时,为两个不同的对象。

不管怎样,此过程均产生了不止一个实例对象,并不是严格的单例模式;

# 7.2.2 使用同步锁解决懒汉式单例问题

第一种,锁类:

public class LazySingleton {
    private static LazySingleton lazySingleton = null;
    private LazySingleton(){}
    public static LazySingleton getInstance(){
        synchronized (LazySingleton.class){
            if(lazySingleton == null){
                lazySingleton = new LazySingleton();
            }
        }
        return lazySingleton;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

同样,Thread-0进入到创建实例语句处:

1548688427482

切换到Thread-1,并试图运行到创建新实例处:

1548688728963

发现Thread-1已被阻塞,进不去,这就是锁的作用;

1548688796653

此时结果满足单例模式

第二种,锁方法:

public class LazySingleton {
    private static LazySingleton lazySingleton = null;

    private LazySingleton() {
    }

    public synchronized static LazySingleton getInstance() {
        if (lazySingleton == null) {
            lazySingleton = new LazySingleton();
        }
        return lazySingleton;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

但是,同步锁方式比较消耗资源,有加锁和解锁的开销,锁类的时候,锁的范围较大,对性能有一定的影响,不建议使用这种模式,可以进一步使用其他方法;

# 7.2.3 Double Check双重检查懒汉式单例模式

前置知识

Java内存模型

Java内存模型的主要目标:定义在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

注意:上边的变量指的是共享变量(实例字段、静态字段、数组对象元素),不包括线程私有变量(局部变量、方法参数),因为私有变量不会存在竞争关系。

内存模型其实就是一张图:

1551104676916

说明:

  • 所有共享变量存于主内存
  • 每一条线程都有自己的工作内存(就是上图所说的本地内存)
  • 工作内存中保存了被该线程使用到的变量的主内存副本

注意:

  • 线程对变量的操作都要在工作内存中进行,不能直接操作主内存
  • 不同的线程之间无法直接访问对方的工作内存中的变量
  • 不同线程之间的变量的传递必须通过主内存

类比:(注意:主内存与工作内存只是一个概念,与堆栈内存没有关系,下边的类比只是帮助理解)

  • 主内存:对应于Java堆中的对象实例数据部分(注意:堆中还保存了对象的其他信息,eg.Mark Word、Klass Point和用于字节对其补白的填充数据)
  • 工作内存:对应于栈中的部分区域

8条内存屏障指令:

下面只列出6条与之后内容相关的,其余的查看《深入理解Java虚拟机》

  • lock:作用于主内存,把一个变量标识为一条线程独占的状态
  • unlock:作用于主内存,把一个处于锁定的变量解锁

下边四条是与volatile实现内存可见性直接相关的四条(store、write、read、load)

  • store:把工作内存中的变量的值传送到主内存中
  • write:把store操作从工作内存中得到的变量值放入到主内存的变量中
  • read:把一个变量的值从主内存中传输到线程的工作内存
  • load:把read操作从主内存中获取到的变量值放入工作内存的变量中去

注意:

  • 一个变量在同一时刻只允许一条线程对其进行lock操作
  • lock操作会将该变量在所有线程工作内存中的变量副本清空,否则就起不到锁的作用了
  • lock操作可被同一条线程多次进行,lock几次,就要unlock几次(可重入锁)
  • unlock之前必须先执行store-write
  • store-write必须成对出现(工作内存-->主内存)
  • read-load必须成对出现(主内存-->工作内存)

变量对所有线程的可见性

可见性:线程1对共享变量的修改能及时被线程2看到

共享变量不可见的原因

  • 共享变量更新后的值没有在工作内存和主内存之间及时更新
  • 线程交错执行
  • 指令重排序结合线程交错执行

实现共享变量及时更新的措施

线程1修改过共享变量后,将共享变量刷到主内存,然后,线程2从主内存读取该共享变量,将该共享变量载入到工作内存中

注意:在短时间内的高并发情况下,如果发生下列三种情况,则线程2就读不到线程1修改过的最新值了,

(1)可能线程1根本来不及将修改过后的共享变量刷到主内存(这个时间非常短,但是还是有)的时候,线程2就已经读取了原有的主内存变量到其工作内存中。

(2)可能线程1虽然将修改过后的值刷到了主内存中,但是线程2的工作内存中的变量副本还没来得及从CPU刷新回来,所以线程2读取到的还是原来的工作内存中的变量副本

(3)可能线程1根本来不及将修改过后的共享变量刷到主内存的时候,同时,线程2的工作内存中的变量副本还没来得及从CPU刷新回来

注意:工作内存中的变量副本在使用之后,不会立刻消失掉,会一直存在,这样其值也一直不变,直到对其进行写操作或数据从CPU中刷新回来(类比volatile-read的作用)。

指令重排序

代码书写顺序与实际执行顺序不同(编译器或处理器为提高程序性能做的优化)

eg.书写代码的顺序如下:

int a = 12;
int b = 13;
int c = a+b;
1
2
3

可能执行的顺序如下:

int b = 13;
int a = 12;
int c = a+b;
1
2
3

volatile的具体实现原理

  • 对volatile变量执行写操作时,会在写操作后加入一条store屏障指令
  • 对volatile变量执行读操作时,会在读操作前加入一条load屏障指令

说人话:

  • 对volatile变量执行读操作时,都要强制的先从主内存读取最新的变量值到工作内存,然后再读工作内存中所存储的变量副本
  • 对volatile变量执行写操作时,又会强制的将工作内存中的刚刚改变的值写到主内存中去

通过上边这样模式,每个线程拿到的volatile变量值都是最新的。

注意:volatile无法实现原子性:

eg. private volatile int count = 0;

假设现在有两条线程分别对count执行加1操作,那么期待的结果最后count==2,但是看下边的分析:

假设有如下流程:

1)线程a获取了count==0;

2)线程b获取了count==0;

3)线程b对count+1,之后写入主内存count==1;

4)线程a对count+1,之后写入主内存count==1;

结果count==1而非count==2,原因就是线程a获取count后,volatile不能实现原子性,这个时候b也能去操作count。

想要实现原子性,使用synchronized去锁住增加方法,或者使用ReentrantLock去锁住增加代码;当然,以上场景使用AtomicInteger更好。

volitile使用场景

(1)运算结果并不依赖当前值,例如Boolean就可,而number++这样的就不行,这样的情况使用锁

(2)运算结果依赖当前值但是能够确保只有单一线程修改变量的值,例如ConcurrentHashMap中Segment的count变量

count变量只能由单一线程来改变(因为put和remove都是加锁的),但是修改后未必能及时刷新到主内存;这时候读线程去读取的话就可能读到旧数据。所以需要volatile来保证可见性。

(3)变量不需要与其他的状态变量共同参与不变约束,例如low<up这样的场景就不行

(4)在访问变量时需要使用锁,就不要使用volatile(《java并发编程实战》)

所以说,volatile只能实现部分线程安全(实际上只能实现可见性)。 如果volatile用得好的话,比synchronized强不少,因为不需要上下文切换。

注:关于volatile禁止指令重排序的介绍去看《深入理解Java虚拟机(第二版)》第十二章"Java内存模型与线程" 通常情况下,能用volatile解决的就不去用synchronized了

通过前面的了解,可以开始讲解双重检查懒汉式单例模式:

public class LazyDoubleCheckSingleton {
    private static LazyDoubleCheckSingleton lazyDoubleCheckSingleton = null;
    private LazyDoubleCheckSingleton(){}
    
    public static LazyDoubleCheckSingleton getInstance(){
        if(lazyDoubleCheckSingleton == null){
            synchronized (LazyDoubleCheckSingleton.class){
                if(lazyDoubleCheckSingleton == null){
                    lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton();
                }
            }
        }
        return lazyDoubleCheckSingleton;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

上面的代码即是双重检查懒汉式单例模式,但是,在多线程的情况下会出现代码重排的情况,实际上,在创建对象的时候(new LazyDoubleCheckSingleton())经历了三个步骤:

(1)分配内存给这个对象;

(2)初始化对象;

(3)设置lazyDoubleCheckSingleton 指向刚分配的内存地址;

代码重排后可能的执行顺序为:

(1)分配内存给这个对象;

(3)设置lazyDoubleCheckSingleton 指向刚分配的内存地址;

(2)初始化对象;

这样,就会导致,新的线程进入看到的lazyDoubleCheckSingleton不为null,就会直接返回lazyDoubleCheckSingleton对象,而实际上对象还未初始化,这样,程序就会报错。

图解如下:

1548685542257

1548685549022

此时,就可以使用volitile修饰变量,实现内存可见性,最终代码如下:

public class LazyDoubleCheckSingleton {
    // volatile共享内存,防止重排序,缓存一致性协议
    private volatile static LazyDoubleCheckSingleton lazyDoubleCheckSingleton = null;
    private LazyDoubleCheckSingleton(){}
    
    public static LazyDoubleCheckSingleton getInstance(){
        if(lazyDoubleCheckSingleton == null){
            synchronized (LazyDoubleCheckSingleton.class){
                if(lazyDoubleCheckSingleton == null){
                    lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton();
                }
            }
        }
        return lazyDoubleCheckSingleton;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 7.2.4 静态内部类--基于类初始化延迟加载懒汉式单例模式

7.3 饿汉式单例模式

Last Updated: 10/20/2019, 11:49:45 PM